El tiempo/reloj como servicio
Cotidianamente como programadores nos topamos con diferentes tareas que de alguna forma requiere manejar el tiempo (fechas, horas, periodos de tiempo), por lo que a menudo es algo en que solemos considerarlo o mirarlo como un simple dato escalar (integer
, float
o string
).
Sin embargo esto no siempre debería serlo y de hecho no lo es, para evitarnos problemas que inconscientemente pateamos para nuestro futuro yo, o no tan en el futuro si procuramos adicionar testing.
Quiero profundizar en explicar el problema...
El ahora y sus implicancias
Pongamos un caso concreto donde el tiempo deja de tener la relevancia de un simple escalar y pasa a tener un impacto mayor, no visible al codificar tal vez, pero si palpable al querer testear nuestro código.
Supongamos que estamos desarrollando un juego, cada vez que un usuario hace login, siempre y cuando sean entre las 08:00 - 12:00 de la mañana vamos a otorgar en forma única un bono de tiempo
<?php
final class UserLoggedInMorningBonusUseCase {
private const BONUS_POINTS = 5;
private $repository;
public function __construct(IUserBonusRepository $repository) {
$this->repository = $repository;
}
public function execute(UserId $id) :void {
$dateTimeNow = new \DateTime();
$morningChecker = new MorningPeriodOfDay();
if(!$morningChecker->verify($dateTimeNow)) {
return;
}
$bonus = new DailyBonus($dateTimeNow, $userId, self::BONUS_POINTS);
if($this->repository->wasApllied($bonus)) {
return;
}
$this->repository->save($bonus);
}
}
Donde nuestra clase MorningPeriodOfDay
resolvería el decidir si nos encontramos en dada una fecha en la zona horaria del servidor, si nos encontramos dentro del periodo que negocio entiende como matutino.
<?php
final class MorningPeriodOfDay {
private const MORNING_START_AT = 8;
private const MORNING_END_AT = 12;
public function verify(\DateTime $time): bool {
$timeHour = (int)$time->format('H');
return $timeHour >= self::MORNING_START_AT
&& $timeHour <= self::MORNING_END_AT;
}
}
Pues si miramos hasta el momento, vemos un código bastante limpio, con nombre de clases, funciones y variables expresivos, que además, en un entorno productivo aparenta resolver lo solicitado sin pasar sobresaltos.
Pero,(en el mundo del software hay muchos peros y por esto siempre "depende"), aunque la clase auxiliar MorningPeriodOfDay
es totalmente testeable, nuestro caso de uso no corre con la misma suerte, de hecho aunque testiemos todos los casos posibles en la clase auxiliar, nuestro caso de uso nos forzara a solo correr la suite de test durante la mañana (zona horaria) de la maquina que intente ejecutarlo.
Inclusive, el hecho de pensar que al ser un caso conocido dentro del equipo de trabajo, si se corre la suite de test pasar por alto los errores que provengan de este caso de uso en particular, hace que perdamos confianza en lo que hemos testeado.
Pequeño lio hemos generado, ni que hablar si tenemos CI/CD, el encargado de la misma los va a querer bien poco.
El tiempo como una dependencia explicita
Un mejor enfoque es el hecho de introducir el tiempo actual como un servicio externo en nuestro caso de uso que nos provea de una respuesta para el momento actual.
En algunos lugares eh visto que le llaman Clock, en otros TimeService pero en definitiva refieren a este mismo concepto.
<?php
interface ITimeService {
public function currentDateTimeUtc() :\DateTime;
public function currentDateTimeAtTimeZone(string $timezone) :\DateTime;
}
Interface de la que luego podremos adherirnos en los tests para que retorne los dobles de pruebas que nos sean útiles para cada caso.
Mientras que la implementación concreta del mismo nos quedaría de la siguiente forma:
<?php
class TimeService implements ITimeService {
public function currentDateTimeUtc() :\DateTime {
return $this->currentDateTimeAtTimeZone('UTC');
}
public function currentDateTimeAtTimeZone(string $timezone) :\DateTime {
return new \DateTime('now', new \DateTimeZone($timezone));
}
}
El caso de uso se vería modificado en la siguiente forma al recibir la dependencia y utilizarla en vez de al contexto tiempo en forma explicita
<?php
final class UserLoggedInMorningBonusUseCase {
private const BONUS_POINTS = 5;
private $repository;
private $timeService;
public function __construct(
IUserBonusRepository $repository,
ITimeService $timeService
) {
$this->repository = $repository;
$this->timeService = $timeService;
}
public function execute(UserId $id) :void {
$dateTimeNow = $this->timeService->currentDateTimeUtc();
$morningChecker = new MorningPeriodOfDay();
if(!$morningChecker->verify($dateTimeNow)) {
return;
}
$bonus = new DailyBonus($dateTimeNow, $userId, self::BONUS_POINTS);
if($this->repository->wasApllied($bonus)) {
return;
}
$this->repository->save($bonus);
}
}
Entonces nuestro caso de uso queda testeable sin mayor problema y bajo nuestro control en forma sencilla (con herramienta de turno de mocking o sin ella -aka. generando el doble de prueba a mano-), sin tener que usar artilugios para poder salvar el problema.
Las invariantes de tiempo en nuestros elementos del dominio
Hay otras ocasiones en que el tiempo se inmiscuye en nuestras restricciones de negocio y particularmente en nuestras entidades, donde no seria bueno que nuestra dependencia de contexto temporal llegase tan adentro como dependencia y resolver lo solicitado de forma no eludible y prohibiendo la construcción de instancias por fuera de lo entendido como válido/correcto.
Pero vamos a terreno con ejemplo, si trabajamos en una compañía de seguros y nos solicitan que las pólizas deben tener fecha de inicio y de fin, no pudiendo tener una duración menor a un (1) año, tampoco es posible asignarle una fecha de inicio inferior a la del día actual en X zona horaria (UTC para simplificar aquí nuestro caso, pero podría adicionarse complejidad aquí en el como seleccionar/determinar una).
<?php
final class InsurancePolicy {
private $startDate;
private $endDate;
public function __construct(
\DateTime $startDate,
\DateTime $endDate
// other needed data
) {
$this->_ensureMinimumPeriodOfValidity($startDate, $endDate);
$this->startDate = $startDate;
$this->endDate = $endDate;
}
private function _ensureMinimumPeriodOfValidity(\DateTime $startDate, \DateTime $endDate) :void {
$interval = new \DateInterval('P1Y');
$minimumEndDate = clone($startDate)->add($interval);
if($endDate >= $minimumEndDate) {
return;
}
throw new \InvalidArgumentException('The given range of time is less than the required one ('.(string)$interval.')');
}
public function startDate() :\DateTime { return $this->startDate; }
public function endDate() :\DateTime { return $this->endDate; }
}
Seguramente a primera vista parece una resolución aceptable, pero la verdad que hay un detalle que esta por fuera, el hecho de validar que el la fecha de inicio sea al menos igual a la actual.
Esto ultimo puede parecer trivial, pero se ah dejado a la suerte del conocimiento de negocio del próximo programador, que en todo lugar donde necesite crearse se lo adicione como un requisito previo. Esto lleva a la larga y en equipos de varias personas a que eventualmente se nos escape en algún lugar, así terminar ocasionando un gran embrollo a negocio... y dependiendo de la relevancia de la entidad donde suceda, el tipo de negocios, contratos y un gran etc, una cantidad X de dinero que puede perderse en una póliza creada desde la sección de nuestra app que tiene esta falla en mantener sus invariantes.
Es por esto de la relevancia de forzar a la entidad a corroborar esto y por ende a requerirlo sin excepción en su constructor, no inyectando la dependencia en este caso, pero si forzarlo al incorporar un parámetro extra.
<?php
final class InsurancePolicy {
private $startDate;
private $endDate;
public function __construct(
\DateTime $currentDate,
\DateTime $startDate,
\DateTime $endDate
// other needed data
) {
$this->_ensureStartDateIsEqualOrGreaterThanNow($currentDate, $startDate);
$this->_ensureMinimumPeriodOfValidity($startDate, $endDate);
$this->startDate = $startDate;
$this->endDate = $endDate;
}
private function _ensureStartDateIsEqualOrGreaterThanNow(\DateTime $currentDate, \DateTime $startDate) :void {
if($startDate >= $currentDate) {
return;
}
throw new \InvalidArgumentException('The start date of the insurance policy must be greater or equal from the current date');
}
// The rest of the code
}
De esta forma no podemos escapar de en todos lados pasar la fecha actual y ver en forma explicita la invariante en nuestra entidad.
Aún así quedan algunos detalles extras que comentar para hacer nuestro labor menos proclive a errores con fechas/zonas horarias/horas ,etc.
La inmutabilidad en la fecha-hora y la unificación de zonas horarias
En el ejemplo de código anterior se vé el uso del patrón creacional Prototype en la función _ensureMinimumPeriodOfValidity
para generar una copia de la fecha de inicio para asi al adicionarle el intervalo de tiempo deseado, que el modificado sea el objeto nuevo con igual valor y no la fecha de inicio en si misma, esto se debe a que la clase \DateTime
opera mutando su estado.
Este es un detalle puntual, pero este problema podemos tenerlo a lo largo de todo el ciclo de vida de nuestro objeto, donde al querer realizar cualquier operativa similar con fecha si no lo hacemos con cuidado podemos caer en modificaciones de datos no deseados. Vale aclarar que la mutabilidad como problema aplica no solo a fechas/horas, si no a cualquier objeto del que nos interese unicamente por el valor que representa/contiene y no por poseer una identidad (aka. Value Objects).
Introduzcamos la inmutabilidad en el ejemplo anterior y quitemos complejidad de estar prestando atención a estos detalles, al estar resueltos por la naturaleza del objeto
<?php
final class InsurancePolicy {
private $startDate;
private $endDate;
public function __construct(
\DateTimeImmutable $currentDate,
\DateTimeImmutable $startDate,
\DateTimeImmutable $endDate
// other needed data
) {
$this->_ensureStartDateIsEqualOrGreaterThanNow($currentDate, $startDate);
$this->_ensureMinimumPeriodOfValidity($startDate, $endDate);
$this->startDate = $startDate;
$this->endDate = $endDate;
}
private function _ensureStartDateIsEqualOrGreaterThanNow(\DateTimeImmutable $currentDate, \DateTimeImmutable $startDate) :void {
if($startDate >= $currentDate) {
return;
}
throw new \InvalidArgumentException("The start date of the insurance policy must be greater or equal from the current date");
}
private function _ensureMinimumPeriodOfValidity(\DateTimeImmutable $startDate, \DateTimeImmutable $endDate) :void {
$interval = new \DateInterval('P1Y');
$minimumEndDate = $startDate->add($interval);
if($endDate >= $minimumEndDate) {
return;
}
throw new \InvalidArgumentException("The given range of time is less than the required one (".(string)$interval.")");
}
public function startDate() :\DateTimeImmutable { return $this->startDate; }
public function endDate() :\DateTimeImmutable { return $this->endDate; }
}
Ahora queda el otro punto del titulo, unificar las zonas horarias, pero iniciemos con el porqué de esto. Es una necesidad imperceptible (?) mientras trabajamos con aplicaciones que solo son utilizadas bajo usuarios en una sola zona horaria, pero cuanto la misma crece, se intercionaliza o regionaliza(hay paises con más de un uso horaria interno) esto pasa a ser un dilema interesante de tratar.
Donde ya no podemos fiarnos de esos CURRENT_DATETIME en nuestro motor de datos relacional, o bien, como el ORM de turno decide almacenar esto en el mecanismo de persistencia elegido, pasa a ser relevante desde el dominio forzar y mantener estar reglas que unifiquen la zona horaria (siendo el estándar el uso de UTC, al menos en todo los lugares donde eh visto) y ya dejar a la capa de presentación de nuestra aplicación el hecho de hacer las conversiones a la del usuario final.
<?php
//...
private function _toUtc(\DateTimeImmutable $date) :\DateTimeImmutable {
$timeZoneUtc = new \DateTimeZone('UTC');
return $date->setTimezone($timeZoneUtc);
}
//...
Por aquí vamos llegando al final de los tips que día a día intento seguir, buscando facilitar el proceso de pruebas unitarias sobre el código que genero y que permita regionalizar-internacionalizar el producto al menor costo/tiempo posible desde mi labor.